fix(plugin-security): stop letting org-admin row count decide whether a platform admin already exists - #17116
Conversation
… a platform admin already exists
The `already_have_admin` short-circuit read `sys_user_permission_set` with no
`orderBy` and a cap of 50, then applied the predicate that actually decides —
`!organization_id` — client-side to whatever 50 rows the driver returned first.
`admin_full_access` is not only the platform-admin set: every organization-scoped
grant of it writes a row carrying the same `permission_set_id`, so the population
grows with the number of org admins. A tenant with fifty-odd of them filled the
window with rows that all fail the filter, the short-circuit did not fire, a
second unscoped grant was minted, and `claimSeedOwnership` re-owned the seeded
business rows to the newly promoted user — silently.
The read is now two legs, both ordered server-side and bounded, and the bound
warns with the number of rows it examined:
Leg A asks the driver the narrow question (`organization_id: null`), so no
org-admin count can crowd the answer out of a window.
Leg B scans the grant population for the set, ordered and bounded, still
applying the exact client-side predicate.
Leg B is not redundant: `organization_id: ''` is storable and reads back as
`''`, which `!organization_id` counts as unscoped and `where: { organization_id:
null }` does not return — so the card's suggested one-line `where` narrowing
would have RELAXED this guard on its own. Both legs are strictly additive to
what the old read could see, so the guard can only fire more often, never less.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
📓 Docs Drift CheckThis PR changes 1 package(s): 5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 5 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 30de5d68fb4d80a8e898329da7bd2606d31068b6 && git checkout 30de5d68fb4d80a8e898329da7bd2606d31068b6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8a70e1bf64e2a63a9a0c597e59524272e0903c8f 57421b4ae457394bf1975c06c52b50820433a9d2 && git checkout -B drift-repro 8a70e1bf64e2a63a9a0c597e59524272e0903c8f && git merge --no-ff 57421b4ae457394bf1975c06c52b50820433a9d2
node scripts/docs-audit/affected-docs.mjs --json 8a70e1bf64e2a63a9a0c597e59524272e0903c8f
|
…rg-admin row count, with its under-cap control The card's reproduction sketch as a cell rather than a failure: 60 organization-scoped grants plus one unscoped human grant whose row sorts last must return already_have_admin, and the SAME fixture with 9 organization-scoped grants must return it too. The under-cap row is the control that proves the fixture measures truncation and not some other difference between the two populations. Also pinned: the `organization_id: ''` legacy holder the narrowed read alone could not have seen; that usr_system still never counts; the reported adminGrantRowsExamined; and the ceiling warning with its under-ceiling control. Counts examined rows by identity rather than by read, so the two legs' overlap does not inflate a number that calls itself rows examined. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
…se combinators in its fake matcher `check:engine-double-contract` RETAINED the two engine doubles the new suite pins, so the ledger learns about them or it never protects the file. `check:where-matcher` flagged the synthetic driver's permission-set matcher as combinator-blind: it now refuses a `$`-prefixed key inside the matcher itself rather than one frame out, so a double that does not implement `$or` says so instead of reporting a row it never understood as absent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
Contract review at
|
Contract review at
|
Adoption record —
|
| check | reading |
|---|---|
| head unmoved | 57421b4a… — same head the verdict pins, pushed 10:44:27Z, no push since |
| CI | verdict read 39 check-runs on this exact head: 34 success, 5 skipped, 0 failed, 0 cancelled, 0 in progress, Lint & Repo Gates green (165 steps, incl. #146 engine-double contract and #147 WHERE-matcher, the two F1/F2 gates). Commit status re-read now: success. |
| Clause-② carriers | check-clause2-carriers.mjs --pair 17116 → exit 0 at 13:38Z, both carriers agreeing in the fixed spelling |
| governed surface | none — 4 changed files, none under docs/adr/** · .claude/** · skills/** · AGENTS.md · CLAUDE.md; Governed Surface Queue Guard green |
| mergeability | mergeable_state: clean; 14 commits on main since merge-base, none touching packages/plugins/plugin-security, engine-double-contract.pinned.json or where-matcher-conformance.baseline.json |
Carriers off, with provenance
needs:contract-review removed from both carriers — PR #17116 and card #16861 — on the authority of verdict 5602846866. ⛔ Removed for that reason and no other; ⛔ this seat cleared nothing on its own judgement.
Findings carried forward, not silently absorbed
- F3 — the ceiling still warns and promotes. Accepted by design in the verdict, matching fix(plugin-security): choose the platform-admin promotion target instead of sampling it — order the candidate read server-side and prefer the declared owner #16863's landed shape and the card's own alternative. ⛔ Not a residue to fix quietly later.
- F4 —
tryFind(:206-223) answers[]on any refusal, which on this guard reads "no admin" ⇒ promote; and now that both legs sendorderBy(and leg Boffset), a driver family refusing either would relax the guard versus the old bare read. Filing as a follow-up card. - F5 — no operator guidance for deployments already bitten: the second unscoped grant and the re-owned seed records persist, and this fix reconciles nothing. Filing as a follow-up card.
- F6 / F7 — test-side tidies (leg-B paging pinned only on the synthetic double; the
$-key refusal now unreachable for an emptypermissionSets).⚠️ Recorded here rather than filed: both are optional and neither is reachable from the subject. The next seat to touch this file should take them.
Release
Ready → auto-merge armed. ⛔ Nothing about F4/F5 blocks this landing: they are pre-existing exposures this PR does not widen, and the p1 the card names is fixed.
Generated by Claude Code
|
Landing provenance — director seat takes the release action under the maintainer's 13:4xZ instruction 「把当前的契约复审全部处理完」 (
Generated by Claude Code |
|
Coordination note — concurrent release actions on this PR, both toward the same terminal state. Director seat (
No conflict in substance: same verdict, same head Generated by Claude Code |
Fixes #16861
What was wrong
packages/plugins/plugin-security/src/bootstrap-platform-admin.ts— the holders read that feeds thealready_have_adminshort-circuit was:No
orderBy, a cap of 50, and the predicate that actually decides —!organization_id— applied client-side to whatever 50 rows the driver returned first.admin_full_accessis not only the platform-admin set: every organization-scoped grant of it writes a row with the samepermission_set_id, so this population grows with the number of org admins. A tenant with fifty-odd of them fills the window with rows that all fail the filter ⇒ the short-circuit does not fire, a second unscoped grant is minted, andclaimSeedOwnershipre-owns the seeded business records to the newly promoted user — silently, because the boot logs a successful promotion exactly as on a fresh install.The guarantee that fails open is #14348 case D: 「Moving an already-granted platform admin is reserved to the maintainer.」
The measurement that chose the fix
The card's suggested
organization_id: nullin thewherewas explicitly not a ruling, and the card named the risk: null matching may not be uniform across driver families. Measured, not reasoned:where: { organization_id: null }driver-sql(better-sqlite3)SysUserPermissionSet/SysPermissionSetdeclarationsdriver-sqlite-wasmdriver-memorydriver-mongodbtranslateFilter— the repo's own precedent for this driver (mongodb-null-comparand-refusal.test.ts: its live suites need a ~123 MB binary download){"organization_id":null}— Mongo's null-or-missing readingdriver-turso⇒ Null matching itself is uniform: every measurable family answers "the column holds no value".
⭐ But that is not the question this code asks, and the difference is a relaxation. Also measured, on both SQL families through ObjectQL and the real objects:
organization_id: ''is storable and reads back as''.!organization_idcounts it unscoped;where: { organization_id: null }does not return it. So a narrowing that replaced the client-side predicate would stop seeing a legacy unscoped holder stored that way — the short-circuit would fire less often and mint the second grant this card is about. ⛔ This card only tightens, so the predicate is untouched and the read is what changed.The shape taken — and why it is consistent with #16863
Two legs, both ordered server-side and bounded, mirroring the candidate scan
9b9581b11landed one read below:{ permission_set_id, organization_id: null }), ordered, bounded. Independent of how many org-scoped grants exist, so no org-admin count can crowd the answer out of a window.orderBystated to the driver, bounded by a ceiling, still applying the exact client-side predicate. This is the leg that still sees a''-shaped legacy row.Both legs are strictly additive to what the old read could see, so the guard can only fire more often than before, never less.
New module-level constants
PLATFORM_ADMIN_GRANT_PAGE_SIZE/PLATFORM_ADMIN_GRANT_SCAN_CEILING— same numbers and same shape as #16863'sPLATFORM_ADMIN_CANDIDATE_*pair (two adjacent reads bounding themselves differently is a future reader's trap), separate constants because the populations are different objects.idasc, and that is measured rather than assumed:tryFindanswers[]when a query is refused, and on this guard[]reads as "no platform admin exists yet", which promotes. An order this object could not serve would be a silent relaxation. Measured honoured on both SQL families through ObjectQL on this very object.Published-surface delta (Clause-② — measured)
@objectstack/plugin-securitypublishesdistwith a single.export built fromsrc/index.ts.bootstrapPlatformAdmin's return object gains one optional key:adminGrantRowsExamined?: number. That is a new key on a published payload ⇒ Clause-② yes,needs:contract-reviewapplied at open time.reasonvalue.already_have_adminis unchanged.PLATFORM_ADMIN_CANDIDATE_PAGE_SIZE/PLATFORM_ADMIN_CANDIDATE_SCAN_CEILINGare exported from the module but are not re-exported fromsrc/index.ts, so fix(plugin-security): choose the platform-admin promotion target instead of sampling it — order the candidate read server-side and prefer the declared owner #16863 did not cross the published.surface with them. The same is true of this PR's two new constants. The carrier here rests on the return-object key alone.Verification
All of the below ran on
57421b4aewith a clean working tree (git status --porcelain --untracked-files=allempty).⭐ The card's cell — measured on both sides
The base read was restored on disk under a
trap … EXIT INT TERM, the mutation proved present by marker count (fix anchor1 -> 0,ABLATION-RESTORED-BASE-READ0 -> 1), and the same 61-row / 10-row fixture run against each. The subject is imported by a RELATIVE specifier inside its own package andpackages/plugins/plugin-security/distdid not exist for these runs, so vitest readsrc/bootstrap-platform-admin.tsdirectly — no build artifact stood between the mutation and the result.Both results were predicted in writing before the run, and both halves matter. The 60-row row is the defect: a second unscoped grant row exists and it belongs to
usr_orgadmin_060— an organization admin promoted to platform admin. The 10-row row is the control: identical code, identical shapes, identical posture, one number changed, and it answersalready_have_adminon the base too. That is what proves the fixture measures truncation rather than some other difference between the two populations.The suite, ablated
The shipped suite against the restored base read:
The six that stayed green are the anti-vacuity case, the three under-cap CONTROL cases, the fresh-install case and the
usr_systemcase — i.e. every case that is not about truncation. Restoration was proved by an emptygit diff HEAD, zero untracked files, and blob equality (git hash-object9ae10b0b3d8f3b1d1367b3646d33e720cd86d0f2=git rev-parse HEAD:PATH), never by an exit code. The temporary harm probe was removed by the same trap.Package suite, typecheck, gates, lint
pnpm --filter @objectstack/plugin-security testpnpm --filter @objectstack/plugin-security typechecktsc --noEmit+tsconfig.scripts.json+check:test-typecheck: OK — 0 file(s) / 0 error(s)node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, every command run, reconciled with--ran✓ 68 derived famil(ies) accounted for — 68 run, 0 NOT-MEASURED, 0 UNRUNpnpm lint(eslint . --no-inline-config)Two derived gates went red and were fixed rather than routed around:
check:engine-double-contractRETAINED the two engine doubles the new suite pins ⇒--writerecorded them inscripts/engine-double-contract.pinned.json(+5 lines, 1 row, 0 lost).check:where-matcherflagged the synthetic driver's permission-set matcher as combinator-blind ⇒ the$-prefix refusal moved inside the matcher callback.Three derived gates first answered exit 3
PREREQUISITE NOT MET— read as NOT MEASURED, never as a pass — because they read built output:check:dual-build-cjs-loads,check:i18n,check:type-check-debt. A fullpnpm build(73/73 tasks successful) was run and all three then returned exit 0. Every gate exit code above was captured before any pipe.验收备注
driver-turso's answer towhere: { organization_id: null }— it needs a reachable remote libSQL endpoint.driver-mongodbwas measured at its translator, which is this repo's own precedent for that driver (mongodb-null-comparand-refusal.test.tsrecords why: its live suites need a ~123 MB binary download), not against a live mongod. Neither gap changes the route taken, because the route deliberately does not depend on null-matching uniformity — leg B applies the same client-side predicate as before on every family.tryFindanswers[]for any refused query, and on this guard[]reads as "no platform admin exists yet", which promotes. That is a pre-existing property of the helper, unchanged by this PR, and it is why the scan order here was measured honoured rather than assumed. It is an observation about a helper's failure mode, not a reproducible defect, so it is recorded here rather than filed.security-plugin.ts. This PR's file list does not include it (0 hits), so nothing changed about that courtesy.''-shaped legacy holder — is measured above and pinned by its own case.Generated by Claude Code
Generated by Claude Code